Table of Contents

  • Исследование мобильного приложения
      • 1.Загрузим файл с данными и изучим общую информацию
      • 2.Подготовим данные
      • 3.Изучим и проверим данные
      • 4.Изучиим воронку событий
      • 5.Изучим результаты эксперимента

Исследование мобильного приложения¶

В исследовании участвует стартап, который продает продукты питания. Нам нужно разобраться как ведут себя пользователи нашего мобильного приложения. Изучить варонки продаж. Провести исследования влияния изменения шрифта на поведение пользователей за время проведения A/A/B-эксперимента.

In [1]:
import pandas as pd
import numpy as np
import math as mth
import seaborn as sns
sns.set(rc={'figure.figsize':(16, 9)})
import matplotlib.pyplot as plt
from scipy import stats as st
import plotly.express as px

from plotly import graph_objects as go
import math
from scipy. special import logsumexp
In [2]:
# загружаем данные 
try:
    data = pd.read_csv(r"B:\Downloads\logs_exp.csv", sep = '\t')

    
except:
    data = pd.read_csv("/datasets/logs_exp.csv", sep = '\t')   

1.Загрузим файл с данными и изучим общую информацию¶

Описание данных

Согласно документации к данным:

Каждая запись в логе — это действие пользователя, или событие.

  • EventName — название события;
  • DeviceIDHash — уникальный идентификатор пользователя;
  • EventTimestamp — время события;
  • ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.
In [3]:
# выводим информацию о данных
display(data.head())
display(data.info())
display(data.describe())
print('Количество дубликатов:', data.duplicated().sum())
EventName DeviceIDHash EventTimestamp ExpId
0 MainScreenAppear 4575588528974610257 1564029816 246
1 MainScreenAppear 7416695313311560658 1564053102 246
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248
3 CartScreenAppear 3518123091307005509 1564054127 248
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 244126 entries, 0 to 244125
Data columns (total 4 columns):
 #   Column          Non-Null Count   Dtype 
---  ------          --------------   ----- 
 0   EventName       244126 non-null  object
 1   DeviceIDHash    244126 non-null  int64 
 2   EventTimestamp  244126 non-null  int64 
 3   ExpId           244126 non-null  int64 
dtypes: int64(3), object(1)
memory usage: 7.5+ MB
None
DeviceIDHash EventTimestamp ExpId
count 2.441260e+05 2.441260e+05 244126.000000
mean 4.627568e+18 1.564914e+09 247.022296
std 2.642425e+18 1.771343e+05 0.824434
min 6.888747e+15 1.564030e+09 246.000000
25% 2.372212e+18 1.564757e+09 246.000000
50% 4.623192e+18 1.564919e+09 247.000000
75% 6.932517e+18 1.565075e+09 248.000000
max 9.222603e+18 1.565213e+09 248.000000
Количество дубликатов: 413

Файл содержит информацию о 244126 событиях.

Время события сохранено в секундах, заголовки столбцов не совсем удобны, заметное количество дубликатов. Исправим это.

2.Подготовим данные¶

In [4]:
# меняем названия столбцов
data.columns = ['event', 'user_id', 'event_time', 'group']
In [5]:
# приводим дату в  формат to_datetime и создаем новый столбец date
data['event_time'] = pd.to_datetime(data['event_time'], unit = 's')
In [6]:
# создаем новый столбец date
data['date'] = pd.to_datetime(data['event_time'].dt.date)
data
Out[6]:
event user_id event_time group date
0 MainScreenAppear 4575588528974610257 2019-07-25 04:43:36 246 2019-07-25
1 MainScreenAppear 7416695313311560658 2019-07-25 11:11:42 246 2019-07-25
2 PaymentScreenSuccessful 3518123091307005509 2019-07-25 11:28:47 248 2019-07-25
3 CartScreenAppear 3518123091307005509 2019-07-25 11:28:47 248 2019-07-25
4 PaymentScreenSuccessful 6217807653094995999 2019-07-25 11:48:42 248 2019-07-25
... ... ... ... ... ...
244121 MainScreenAppear 4599628364049201812 2019-08-07 21:12:25 247 2019-08-07
244122 MainScreenAppear 5849806612437486590 2019-08-07 21:13:59 246 2019-08-07
244123 MainScreenAppear 5746969938801999050 2019-08-07 21:14:43 246 2019-08-07
244124 MainScreenAppear 5746969938801999050 2019-08-07 21:14:58 246 2019-08-07
244125 OffersScreenAppear 5746969938801999050 2019-08-07 21:15:17 246 2019-08-07

244126 rows × 5 columns

In [7]:
# удаляем дубликаты
data = data.drop_duplicates().reset_index(drop=True)
print('Количество дубликатов:', data.duplicated().sum())
Количество дубликатов: 0
In [8]:
# выводим информацию о данных после обработки
display(data.info())
data.head(5)
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 243713 entries, 0 to 243712
Data columns (total 5 columns):
 #   Column      Non-Null Count   Dtype         
---  ------      --------------   -----         
 0   event       243713 non-null  object        
 1   user_id     243713 non-null  int64         
 2   event_time  243713 non-null  datetime64[ns]
 3   group       243713 non-null  int64         
 4   date        243713 non-null  datetime64[ns]
dtypes: datetime64[ns](2), int64(2), object(1)
memory usage: 9.3+ MB
None
Out[8]:
event user_id event_time group date
0 MainScreenAppear 4575588528974610257 2019-07-25 04:43:36 246 2019-07-25
1 MainScreenAppear 7416695313311560658 2019-07-25 11:11:42 246 2019-07-25
2 PaymentScreenSuccessful 3518123091307005509 2019-07-25 11:28:47 248 2019-07-25
3 CartScreenAppear 3518123091307005509 2019-07-25 11:28:47 248 2019-07-25
4 PaymentScreenSuccessful 6217807653094995999 2019-07-25 11:48:42 248 2019-07-25

Изменили названия столбцов. Время событий в секундах привели в формат to_datetime и создали новый столбец date.Удалили дубликаты.

3.Изучим и проверим данные¶

  • Сколько всего событий в логе?
  • Сколько всего пользователей в логе?
  • Сколько в среднем событий приходится на пользователя?
  • Данными за какой период вы располагаете? Найдите максимальную и минимальную дату. Постройте гистограмму по дате и времени. Можно ли быть уверенным, что у вас одинаково полные данные за весь период? Технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные». Определите, с какого момента данные полные и отбросьте более старые. Данными за какой период времени вы располагаете на самом деле?
  • Много ли событий и пользователей вы потеряли, отбросив старые данные?
  • Проверьте, что у вас есть пользователи из всех трёх экспериментальных групп.

Распределение событий по пользователям

In [9]:
print(f'Всего в логе осталось {len(data)} событий.')
print(f'Всего пользователей в логе {len(data.user_id.unique())}.')
print(f'В среднем на пользователя приходится {int(len(data) / len(data.user_id.unique()))} события.')
data.groupby('user_id')[['event']].count().describe(percentiles=[0.05, 1/4, 1/2, 3/4, 0.95, 0.99])
Всего в логе осталось 243713 событий.
Всего пользователей в логе 7551.
В среднем на пользователя приходится 32 события.
Out[9]:
event
count 7551.000000
mean 32.275593
std 65.154219
min 1.000000
5% 3.000000
25% 9.000000
50% 20.000000
75% 37.000000
95% 89.000000
99% 200.500000
max 2307.000000

В среднем, на каждого пользователя приходится 32 события. Медианное количество при этом составляет 20, и всего у одного процента пользователей количество событий переваливает за 200.

In [10]:
plt.figure(figsize=(15, 7))
sns.histplot(data=data.groupby('user_id')[['event']].count(), x='event', kde=True)
plt.title('Распределение событий по пользователям')
plt.xlabel('Количество событий')
plt.ylabel('Количество пользователей')
plt.xlim(0,201)
plt.show()

Оценим, 32 события на пользователя - много ли это? Кажется, что не очень. Посмотрим, по сколько раз пользователи заходят в приложение - соберем события по людям и датам, считая, что за день - только одна сессия.

In [11]:
users_dates = data.groupby(['user_id', 'date'])['event'].count().reset_index()
# количество событий за сессию
users_dates.event.describe(percentiles=[0.05, 1/4, 1/2, 3/4, 0.95, 0.99])
Out[11]:
count    27226.000000
mean         8.951480
std         22.977802
min          1.000000
5%           1.000000
25%          3.000000
50%          5.000000
75%         10.000000
95%         24.000000
99%         51.000000
max       2190.000000
Name: event, dtype: float64

Получается, в среднем на сессию приходится 5 событий. При четырех основных этапов - главная, каталог, корзина, оплата - это логично, например, после оплаты вернуться на главную автоматически. Некоторые листают приложение дольше.

Посмотрим, по сколько сессий приходится на человека

In [12]:
print(users_dates.groupby('user_id')['date'].count().describe(percentiles=[0.05, 1/4, 1/2, 3/4, 0.95, 0.99]))
count    7551.000000
mean        3.605615
std         1.951651
min         1.000000
5%          1.000000
25%         2.000000
50%         3.000000
75%         5.000000
95%         7.000000
99%         8.000000
max         9.000000
Name: date, dtype: float64
In [13]:
plt.figure(figsize=(10, 5))
sns.histplot(data=users_dates.groupby('user_id')[['date']].count(), x='date', bins=8)
plt.title('Распределение визитов по пользователям')
plt.xlabel('Количество дней-визитов')
plt.ylabel('Количество пользователей')
plt.show()

Медианное количество пользователей 3 за две недели зашел в приложение и нажал на 5 страничек.

Распределение логов по времени

In [14]:
# находим максимальную дату в логе
print(data.event_time.max())
2019-08-07 21:15:17
In [15]:
# находим минимальную дату в логе
print(data.event_time.min())
2019-07-25 04:43:36
In [16]:
# считаем количество event по date
data.groupby(by='date').agg({'event': 'count'}).sort_values(by='event', ascending=False).reset_index()
Out[16]:
date event
0 2019-08-01 36141
1 2019-08-05 36058
2 2019-08-06 35788
3 2019-08-02 35554
4 2019-08-03 33282
5 2019-08-04 32968
6 2019-08-07 31096
7 2019-07-31 2030
8 2019-07-30 412
9 2019-07-29 184
10 2019-07-28 105
11 2019-07-27 55
12 2019-07-26 31
13 2019-07-25 9
In [17]:
# строим гистограмму по дате и времени 

plt.figure(figsize=(15, 7))
data['date'].hist(bins=30)
plt.title('Гистограмма по дате и времени')
plt.xlabel("Дата")
plt.ylabel("Частота")
plt.xticks(rotation=45)
plt.show()
In [18]:
# код ревьюера
data['event_time'].hist(bins=14*24, figsize=(14, 5));

В имеющихся данных информация за две недели - с 25 июля по 7 августа 2019 года. 1 августа происходит резкий скачок количества событий - с двух тысяч до 36. Скорее всего, во вторую неделю проходила рекламная кампания, которая так повлияла на данные. Следует считать данные первой недели устаревшими. Отфильтруем их.

In [19]:
# отфильтровываем данные по дате
data_2w = data.query('date >= datetime(2019, 8, 1).date()')
print(data_2w.info())
print()
print(f'{(len(data_2w) / len(data)):.2%} - доля событий второй недели')
print(f'Всего пользователей во второй неделе {len(data_2w.user_id.unique())}.')
print()
print(data_2w.groupby('group')['user_id'].nunique())
<class 'pandas.core.frame.DataFrame'>
Int64Index: 240887 entries, 2826 to 243712
Data columns (total 5 columns):
 #   Column      Non-Null Count   Dtype         
---  ------      --------------   -----         
 0   event       240887 non-null  object        
 1   user_id     240887 non-null  int64         
 2   event_time  240887 non-null  datetime64[ns]
 3   group       240887 non-null  int64         
 4   date        240887 non-null  datetime64[ns]
dtypes: datetime64[ns](2), int64(2), object(1)
memory usage: 11.0+ MB
None

98.84% - доля событий второй недели
Всего пользователей во второй неделе 7534.

group
246    2484
247    2513
248    2537
Name: user_id, dtype: int64
In [20]:
print('БЕЗ ЧИСТКИ события: '  , data['date'].count())
print('ПОСЛЕ ЧИСТКИ события: ' , data_2w['date'].count())
print('Потеряли событий: '  , (data['date'].count()- data_2w['date'].count())/data['date'].count()*100)
БЕЗ ЧИСТКИ события:  243713
ПОСЛЕ ЧИСТКИ события:  240887
Потеряли событий:  1.159560630741897
In [21]:
print('БЕЗ ЧИСТКИ пользователи: '  , data['user_id'].nunique())
print('ПОСЛЕ ЧИСТКИ пользователи: ' , data_2w['user_id'].nunique())
print('Потеряли пользователей: '  , (data['user_id'].nunique()-data_2w['user_id'].nunique()) / data['user_id'].nunique()*100)
БЕЗ ЧИСТКИ пользователи:  7551
ПОСЛЕ ЧИСТКИ пользователи:  7534
Потеряли пользователей:  0.22513574361011784
In [22]:
# сравниваем группы 246-247 и 247-248
group_user = data_2w.groupby('group').agg({'user_id': 'nunique'})
group_user['lost_user'] = group_user.user_id/ group_user.user_id.shift(1)
group_user.reset_index()
Out[22]:
group user_id lost_user
0 246 2484 NaN
1 247 2513 1.011675
2 248 2537 1.009550

Посчитала долю от предыдущей группы между 246 -247 и 247-248.Тем самым подтвердив, что размер группы не сильно отличаются друг от друга.

Таким образом, во вторую неделю попадет почти 99% лога и 7534 из 7551 пользователей. Среди них представители всех трех групп эксперимента и размеры групп примерно равны.

4.Изучиим воронку событий¶

  • Посмотрите, какие события есть в логах, как часто они встречаются. Отсортируйте события по частоте.
  • Посчитайте, сколько пользователей совершали каждое из этих событий. Отсортируйте события по числу пользователей. Посчитайте долю пользователей, которые хоть раз совершали событие.
  • Предположите, в каком порядке происходят события. Все ли они выстраиваются в последовательную цепочку? Их не нужно учитывать при расчёте воронки.
  • По воронке событий посчитайте, какая доля пользователей проходит на следующий шаг воронки (от числа пользователей на предыдущем). То есть для последовательности событий A → B → C посчитайте отношение числа пользователей с событием B к количеству пользователей с событием A, а также отношение числа пользователей с событием C к количеству пользователей с событием B.
  • На каком шаге теряете больше всего пользователей?
  • Какая доля пользователей доходит от первого события до оплаты?
In [23]:
# посмотрим количество событий  в логах
data_user_count = data_2w.groupby('event').agg({'user_id': 'count'}).sort_values(by='user_id', ascending=False).reset_index()
data_user_count
Out[23]:
event user_id
0 MainScreenAppear 117328
1 OffersScreenAppear 46333
2 CartScreenAppear 42303
3 PaymentScreenSuccessful 33918
4 Tutorial 1005
In [24]:
# посчитаем долю пользователей, которые хоть раз совершали событие.
funnel = data_2w.groupby('event').agg({'event': 'count','user_id': 'nunique'}).sort_values(by='user_id', ascending=False)
funnel.set_axis(['count_event','uniq_users'], axis='columns', inplace=True)      
funnel['share_event'] = round(funnel.uniq_users / data_2w['user_id'].nunique()*100,2)
funnel.reset_index()
Out[24]:
event count_event uniq_users share_event
0 MainScreenAppear 117328 7419 98.47
1 OffersScreenAppear 46333 4593 60.96
2 CartScreenAppear 42303 3734 49.56
3 PaymentScreenSuccessful 33918 3539 46.97
4 Tutorial 1005 840 11.15

Всего 5 событий: MainScreenAppear, OffersScreenAppear, CartScreenAppear, PaymentScreenSuccessful, Tutorial.

Порядок происхождения событий.

  • MainScreenAppear - просмотр главный страницы(98,47%)
  • OffersScreenAppear - просмотр страницы предложений(60,96%)
  • CartScreenAppear - просмотр страницы корзины(49,56%)
  • PaymentScreenSuccessful - переход на страницу успешной оплаты(46,97%)

Первые четыре события образуют последовательную цепочку движения пользователя по приложению. Обучение же не является обязательным этапом.

Не будем учитывать Tutorial - руководство(просмотр обучающей информации для новых пользователей) при расчете воронки(11,15%).

In [25]:
# убираем событие Tutorial
data_4event_name= data_2w.query('event != "Tutorial"')
In [26]:
data_4event_name['user_id'].nunique()
Out[26]:
7530
In [27]:
# сгруппируем таблицу по событиям
event_users = data_4event_name.groupby('event').agg({'user_id':'nunique'}).reset_index()
event_users.set_axis(['event', 'users_count'], axis='columns', inplace=True)
# добавитим столбец - какая доля уникальных пользователей совершала это событие
event_users['share_user'] = round(event_users.users_count / data_4event_name['user_id'].nunique()*100)
event_users = event_users.sort_values('users_count', ascending=False).reset_index(drop=True)

# построим воронку с помощью shift
event_users['funnel'] = round(event_users.users_count /  event_users.users_count.shift(1)*100,)
event_users
Out[27]:
event users_count share_user funnel
0 MainScreenAppear 7419 99.0 NaN
1 OffersScreenAppear 4593 61.0 62.0
2 CartScreenAppear 3734 50.0 81.0
3 PaymentScreenSuccessful 3539 47.0 95.0
In [28]:
# сгруппируем таблицу по событиям
event_users = data_4event_name.groupby('event').agg({'user_id':'nunique'}).reset_index()
event_users.set_axis(['event', 'users_count'], axis='columns', inplace=True)
# добавитим столбец - какая доля уникальных пользователей совершала это событие
event_users['share_user'] = round(event_users.users_count / data_4event_name['user_id'].nunique()*100)
event_users = event_users.sort_values('users_count', ascending=False).reset_index(drop=True)

# построим воронку
event_users['funnel'] = 1
for i in range(1, 4):
    event_users.loc[i, 'funnel'] = event_users.loc[i, 'users_count'] /  (event_users.loc[i-1, 'users_count'])


event_users['funnel'] = round(event_users['funnel']*100)
event_users
Out[28]:
event users_count share_user funnel
0 MainScreenAppear 7419 99.0 100.0
1 OffersScreenAppear 4593 61.0 62.0
2 CartScreenAppear 3734 50.0 81.0
3 PaymentScreenSuccessful 3539 47.0 95.0

Для последовательности событий A → B → C посчитайте отношение числа пользователей с событием B к количеству пользователей с событием A, а также отношение числа пользователей с событием C к количеству пользователей с событием B.

In [29]:
# строим воронку
fig = go.Figure(go.Funnel(y = event_users['event'],
                          x = event_users['users_count'],
                          opacity = 0.6,
                          textposition = 'inside',
                          textinfo = 'value + percent previous'))
fig.update_layout(title_text='Воронка событий', title_x = 0.5)
fig.show()

Даже первый шаг воронки проходит не 100 % пользователей - получается, на главную страницу заходить не обязательно. Самый большой скачок посещаемости - между главной страницей и страницей предложений (каталогом) - почти 38 % пользователей туда не приходят. Зато на следующих шагах отсеивается всего 19 % и 5 % пользователей соответветственно. Всего до успешной оплаты доходят 47% пользователей приложения.

5.Изучим результаты эксперимента¶

  • Сколько пользователей в каждой экспериментальной группе?
  • Есть 2 контрольные группы для А/А-эксперимента, чтобы проверить корректность всех механизмов и расчётов. Проверьте, находят ли статистические критерии разницу между выборками 246 и 247.
  • Выберите самое популярное событие. Посчитайте число пользователей, совершивших это событие в каждой из контрольных групп. - Посчитайте долю пользователей, совершивших это событие. Проверьте, будет ли отличие между группами статистически достоверным. Проделайте то же самое для всех других событий (удобно обернуть проверку в отдельную функцию). Можно ли сказать, что разбиение на группы работает корректно?
  • Аналогично поступите с группой с изменённым шрифтом. Сравните результаты с каждой из контрольных групп в отдельности по каждому событию. Сравните результаты с объединённой контрольной группой. Какие выводы из эксперимента можно сделать?
  • Какой уровень значимости вы выбрали при проверке статистических гипотез выше? Посчитайте, сколько проверок статистических гипотез вы сделали. При уровне значимости 0.1 каждый десятый раз можно получать ложный результат. Какой уровень значимости стоит применить? Если вы хотите изменить его, проделайте предыдущие пункты и проверьте свои выводы.
In [30]:
# посчитаем количество пользователей по группам
users = data_4event_name.groupby('group')['user_id'].nunique()
users
Out[30]:
group
246    2483
247    2512
248    2535
Name: user_id, dtype: int64

Всего имеются три группы пользователей: группы 246 и 247 - контрольные, группа 248 - экспериментальная. В них соответственно по 2483, 2512 и 2535 человек.

Мы знаем, что у нас есть 2 контрольные группы для А/А-эксперимента (246 и 247), чтобы проверить корректность всех механизмов и расчётов, и одна тестовая группа (В, 248).

Критерии успешного A/A теста:

  • Количество пользователей в различных группах различается не более, чем на 1%;
  • Для всех групп фиксируют и отправляют в системы аналитики данные об одном и том же;
  • Различие ключевых метрик по группам не превышает 1% и не имеет статистической значимости;
  • Попавший в одну из групп посетитель остаётся в этой группе до конца теста.
In [31]:
print(f'Разница между группой 246 и 247 составляет {data_4event_name.query("group == 247")["user_id"].nunique() / data_4event_name.query("group == 246")["user_id"].nunique():.2}', '%')
Разница между группой 246 и 247 составляет 1.0 %

Количество пользователей в каждой из групп достаточно большое для проведения исследования и разница между размерами групп 246 и 247 незначительная.

In [32]:
# распределение пользователей по группам
len(data_4event_name.groupby('user_id')['group'].nunique().reset_index().query('group > 1'))
Out[32]:
0

Среди пользователей нет попавших в несколько групп.

Остается проверить различие ключевых метрик. Для каждого события подсчитаем, какая доля пользователей в каждой группе его совершила, и проверим, является ли отличие между группами статистически достоверным.

In [33]:
# формируем события по группам
event_group = (data_4event_name.
          groupby(['event', 'group']).
          agg({'user_id': 'nunique'}).
          reset_index().
          rename(columns={'user_id' : 'total_users'}).
          sort_values(by=['group','total_users'], ascending=False))

event_group
Out[33]:
event group total_users
5 MainScreenAppear 248 2493
8 OffersScreenAppear 248 1531
2 CartScreenAppear 248 1230
11 PaymentScreenSuccessful 248 1181
4 MainScreenAppear 247 2476
7 OffersScreenAppear 247 1520
1 CartScreenAppear 247 1238
10 PaymentScreenSuccessful 247 1158
3 MainScreenAppear 246 2450
6 OffersScreenAppear 246 1542
0 CartScreenAppear 246 1266
9 PaymentScreenSuccessful 246 1200
In [34]:
#строим воронку событий в разрезе тестовых групп
fig = go.Figure()

fig.add_trace(go.Funnel(name = '246',
                        y = event_group.query('group == 246')['event'],
                        x = event_group.query('group == 246')['total_users'],
                        opacity = 0.7,
                        textposition = 'inside',
                        textinfo = 'value + percent previous'))

fig.add_trace(go.Funnel(name = '247',
                        y = event_group.query('group == 247')['event'],
                        x = event_group.query('group == 247')['total_users'],
                        opacity = 0.7,
                        textposition = 'inside',
                        textinfo = 'value + percent previous'))


fig.add_trace(go.Funnel(name = '248',
                        y = event_group.query('group == 248')['event'],
                        x = event_group.query('group == 248')['total_users'],
                        opacity = 0.7,
                        textposition = 'inside',
                        textinfo = 'value + percent previous'))
                        
fig.update_layout(title_text='Воронка событий по группам' , title_x = 0.5)
fig.show()

В А/В-тестировании проверяем гипотезу о равенстве выборок.

Но сначала проведем (А/А-тест) и проверим находят ли статистические критерии разницу между выборками 246 и 247.

Будем использовать z-критерий. Это статистический тест, который позволяет определить различия между двумя средними значениями генеральной совокупности(когда дисперсии известны и размер выборки велик).

Напишем функцию:

Функция принимает на вход два датафрейма с логами и по заданному событию попарно проверяет есть ли статистически значимая разница между долями пользователей, совершивших его в группе 1 и группе 2.

Входные параметры:

  • trial1, trial2 - датафреймы с логами
  • event - событие
  • alpfa - критический уровень статистической значимости
  • n - поправка Боннферони для критического уровня статистической значимости
In [35]:
def z_test(trial1, trial2, event, alpha, n):
    
    # критический уровень статистической значимости c поправкой Бонферрони
    bonferroni_alpha = alpha / n
 
    # число пользователей в группе 1 и группе 2:
    users_new = np.array([trial1['user_id'].nunique(), 
                        trial2['user_id'].nunique()])

    # число пользователей, совершивших событие в группе 1 и группе 2
    success = np.array([trial1[trial1['event'] == event]['user_id'].nunique(), 
                        trial2[trial2['event'] == event]['user_id'].nunique()])    
    # пропорции успехов в группах:
    p1 = success[0]/users_new[0]
    p2 = success[1]/users_new[1]
    
    # пропорция успехов в комбинированном датасете:
    p_combined = (success[0] + success[1]) / (users_new[0] + users_new[1])

    # разница пропорций в датасетах
    difference = p1 - p2 

    # считаем статистику в ст.отклонениях стандартного нормального распределения
    z_value = difference /  np.sqrt(p_combined * (1 - p_combined) * (1/users_new[0] + 1/users_new[1]))

    # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
    distr = st.norm(0, 1)  

    p_value = (1 - distr.cdf(abs(z_value))) * 2   #тест двусторонний, удваиваем результат
    
    print('Событие:', event)
    print('p-значение: ', p_value)

    if p_value < bonferroni_alpha:
        print('Отвергаем нулевую гипотезу: между долями есть разница')
    else:
        print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')

Сопоставим доли по каждому событию(контрольными и экспериментальнмы группами):

  • 2 контрольные группы: 246 и 247
  • 1 контрольная группа и 1 экспериментальная группа: 246-247 и 247-248
  • 2 контрольные и экспериментальная: 246+247 и 248

В нашем исследовании 4 вида событий:

MainScreenAppear, OffersScreenAppear, CartScreenAppear, PaymentScreenSuccessful.

Значит будет проведено 4 A/A теста и 12 А/В. Чтобы застроховать себя от ложного резульата вводим поправку Бонферрони bonferroni_alpha = alpha / 4 для A/A теста и bonferroni_alpha = alpha / 12 для А/В.

Формулируем гипотизу:

Гипотеза H0: между долями нет значимой разницы

Гипотеза H1: между долями есть значимая разница

In [36]:
data_4event_name[data_4event_name['group'] == 246]
Out[36]:
event user_id event_time group date
2827 MainScreenAppear 3737462046622621720 2019-08-01 00:08:00 246 2019-08-01
2828 MainScreenAppear 3737462046622621720 2019-08-01 00:08:55 246 2019-08-01
2829 OffersScreenAppear 3737462046622621720 2019-08-01 00:08:58 246 2019-08-01
2832 OffersScreenAppear 3737462046622621720 2019-08-01 00:10:26 246 2019-08-01
2833 MainScreenAppear 3737462046622621720 2019-08-01 00:10:47 246 2019-08-01
... ... ... ... ... ...
243707 MainScreenAppear 5746969938801999050 2019-08-07 21:12:11 246 2019-08-07
243709 MainScreenAppear 5849806612437486590 2019-08-07 21:13:59 246 2019-08-07
243710 MainScreenAppear 5746969938801999050 2019-08-07 21:14:43 246 2019-08-07
243711 MainScreenAppear 5746969938801999050 2019-08-07 21:14:58 246 2019-08-07
243712 OffersScreenAppear 5746969938801999050 2019-08-07 21:15:17 246 2019-08-07

78985 rows × 5 columns

In [37]:
# считаем статистическую значимость между контрольными группами 246 и 247:

for event in event_group['event'].unique():
    z_test(data_4event_name[data_4event_name['group'] == 246], data_4event_name[data_4event_name['group'] == 247], event, 0.05, 4)
    print()
Событие: MainScreenAppear
p-значение:  0.7526703436483038
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: OffersScreenAppear
p-значение:  0.24786096925282264
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: CartScreenAppear
p-значение:  0.22867643757335676
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: PaymentScreenSuccessful
p-значение:  0.11446627829276612
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

По резульату А/А теста между группами 246 и 247 ни по одному событию нет статистически достоверного отличия при заданном уровне значимости. Самое min значение у события PaymentScreenSuccessful - 0.114.

Запускаем А/В тест.

In [38]:
# считаем статистическую значимость между контрольной и экспериментальной группами 246 и 248:

for event in event_group['event'].unique():
    z_test(data_4event_name[data_4event_name['group'] == 246], data_4event_name[data_4event_name['group'] == 248], event, 0.05, 12)
    print()
Событие: MainScreenAppear
p-значение:  0.3387114076159288
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: OffersScreenAppear
p-значение:  0.21442476639710506
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: CartScreenAppear
p-значение:  0.08067367598823139
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: PaymentScreenSuccessful
p-значение:  0.21693033984516674
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

По резульату А/B теста между группами 246 и 248 ни по одному событию нет статистически достоверного отличия при заданном уровне значимости. Самое min значение у события CartScreenAppear - 0.081.

In [39]:
# считаем статистическую значимость между контрольной и экспериментальной группами 247 и 248:

for event in event_group['event'].unique():
    z_test(data_4event_name[data_4event_name['group'] == 247], data_4event_name[data_4event_name['group'] == 248], event, 0.05, 12)
    print()
Событие: MainScreenAppear
p-значение:  0.5194964354051703
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: OffersScreenAppear
p-значение:  0.9333751305879443
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: CartScreenAppear
p-значение:  0.5878284605111943
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: PaymentScreenSuccessful
p-значение:  0.7275718682261119
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

По резульату А/B теста между группами 247 и 248 ни по одному событию нет статистически достоверного отличия при заданном уровне значимости. Самое min значение у события MainScreenAppear - 0.519.

In [40]:
# считаем статистическую значимость между объединённой контрольной 246+247 и экпериментальной 248 группами:

for event in event_group['event'].unique():
    z_test(data_4event_name[data_4event_name['group'] != 248], data_4event_name[data_4event_name['group'] == 248], event, 0.05,12)
    print()
Событие: MainScreenAppear
p-значение:  0.3486684291093256
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: OffersScreenAppear
p-значение:  0.44582745409482394
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: CartScreenAppear
p-значение:  0.18683558686831558
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Событие: PaymentScreenSuccessful
p-значение:  0.6107918742187335
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

По резульату А/B теста между группами 246+247 и 248 ни по одному событию нет статистически достоверного отличия при заданном уровне значимости. Самое min значение у события CartScreenAppear - 0.187.

Так как в нашем эксперименте 4 пары групп и 4 этапа в воронке, число гипотез равно 16, и используя поправку Бонферрони, значение p-value следовало бы уменьшить до 0,003(0.05/16).

ВЫВОДЫ:

В ходе исследования мы изучили данные о работе мобильного приложения-магазина. В анализе участвовали четыре события, образующие последовательные цепочки движения пользователя по приложению: MainScreenAppear-7419, OffersScreenAppear - 4593, CartScreenAppear - 3734, PaymentScreenSuccessful - 3539. Tutorial было исключено из анализа ввиду необязательного прохождения и отсутствия влияния на остальные шаги.

Были проанализированы поведение покупателей на основании логов пользователей, а так же, результаты А/А/В-теста. Тест был запущен 1-го августа, а предыдущие события - это старые логи, которые "доехали" в выборку.Даже первый шаг воронки проходит не 100 % пользователей - получается, на главную страницу заходить не обязательно. Самый большой скачок посещаемости - между главной страницей и страницей предложений (каталогом) - почти 38 % пользователей туда не приходят. Возможно, следует поискать проблему в этом этапе. Зато на следующих шагах отсеивается всего 19 % и 5 % пользователей соответветственно. Всего до успешной оплаты доходят 47% пользователей приложения.

В анализе участвовало:2483 пользователя - 246 группы; 2512 пользователя - 247 группы; 2535 пользователя - 248 группы, где 246-247-это контрольные группы, а 248 - экспериментальная.

Сопоставили доли по каждому событию(контрольными и экспериментальнмы группами):

  • 2 контрольные группы: 246 и 247
  • 1 контрольная группа и 1 экспериментальная группа: 246-247 и 247-248
  • 2 контрольные и экспериментальная: 246+247 и 248.

Всего проведено 16 тестов: 4 A/A теста и 12 А/В.

За время проведения A/A/B-эксперимента по каждому из событий не обнаружили статистически значимой разницы между группами. Из этого можно сделать вывод, что изменение шрифтов во всём приложении на поведение пользователей не влияет. Можно поиграть с цветом(шрифта), формой и размером приложения. Проанализировать, почему теряется большое количество пользователей при переходе : на страницы предложений и корзины.

In [41]:
# посчитаем количество пользователей по группам
users = data_4event_name.pivot_table(index = 'group', values = 'user_id',  aggfunc = 'nunique')
users
Out[41]:
user_id
group
246 2483
247 2512
248 2535
In [42]:
# Сделам таблицу с числом уникальных пользователей по событиям и контрольным группам
pivot_log = data_4event_name.pivot_table(index='group',columns = 'event', values='user_id',aggfunc='nunique').sort_values(by = 'group', ascending = True)
pivot_log['total'] = [users['user_id'].iloc[0], users['user_id'].iloc[1], users['user_id'].iloc[2]]

pivot_log
Out[42]:
event CartScreenAppear MainScreenAppear OffersScreenAppear PaymentScreenSuccessful total
group
246 1266 2450 1542 1200 2483
247 1238 2476 1520 1158 2512
248 1230 2493 1531 1181 2535
In [43]:
# добавляем столбец 246+247
new_row = pivot_log.loc[246] + pivot_log.loc[247]
new_row.name = '246_247'
pivot_log = pivot_log.append([new_row])
pivot_log
C:\Users\Tima\AppData\Local\Temp\ipykernel_7008\3657894602.py:4: FutureWarning:

The frame.append method is deprecated and will be removed from pandas in a future version. Use pandas.concat instead.

Out[43]:
event CartScreenAppear MainScreenAppear OffersScreenAppear PaymentScreenSuccessful total
group
246 1266 2450 1542 1200 2483
247 1238 2476 1520 1158 2512
248 1230 2493 1531 1181 2535
246_247 2504 4926 3062 2358 4995
In [44]:
# создаем функцию
def z_test(part0, part1, total0, total1, alpha,n):  
        
    # доля успехов в первой группе:
    p1 = part0 / total0 
    
    # доля успехов во второй группе:
    p2 = part1 / total1 
    
    # комбинированная доля успехов:
    p_combined = (part0 + part1) / (total0 + total1) 
    
    #разница в долях между группами:
    difference = p1 - p2 
    
    # считаем статистику в ст.отклонениях стандартного нормального распределения:
    z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/total0 + 1/total1))
    
    #задаем стандарное нормальное отклонение(среднее 0, ст.отклонение 1):
    distr = st.norm(0, 1) 
    p_value = (1 - distr.cdf(abs(z_value))) * 2 #тест двусторонний, удваиваем результат
    
    bonferroni_alpha = alpha / n
    
    print('Проверка для групп {} и {} событие: {}, p-значение: {p_value:.2f}'.format(part0, part1, event,p_value=p_value))
    if p_value < bonferroni_alpha:
        print("Отвергаем нулевую гипотезу: между долями есть разница")
    else:
        print("Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными")   

А/А-тест

In [45]:
# считаем статистическую значимость между контрольными группами 246 и 247:

group_1 = 246
current_row = pivot_log.loc[group_1]
group_2 = 247
for event_number in range(4):
    event = pivot_log.columns[event_number]
    p_value_result = z_test(current_row[event], pivot_log[event][group_2], current_row['total'], pivot_log['total'][group_2], 0.05,4)
Проверка для групп 1266 и 1238 событие: CartScreenAppear, p-значение: 0.23
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 2450 и 2476 событие: MainScreenAppear, p-значение: 0.75
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 1542 и 1520 событие: OffersScreenAppear, p-значение: 0.25
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 1200 и 1158 событие: PaymentScreenSuccessful, p-значение: 0.11
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

А/B-тесты

In [46]:
# считаем статистическую значимость между контрольными группами 246 и 248:

group_1 = 246
current_row = pivot_log.loc[group_1]
group_2 = 248
for event_number in range(4):
    event = pivot_log.columns[event_number]
    p_value_result = z_test(current_row[event], pivot_log[event][group_2], current_row['total'], pivot_log['total'][group_2], 0.05, 12)
Проверка для групп 1266 и 1230 событие: CartScreenAppear, p-значение: 0.08
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 2450 и 2493 событие: MainScreenAppear, p-значение: 0.34
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 1542 и 1531 событие: OffersScreenAppear, p-значение: 0.21
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 1200 и 1181 событие: PaymentScreenSuccessful, p-значение: 0.22
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
In [47]:
# считаем статистическую значимость между контрольными группами 247 и 248:

group_1 = 247
current_row = pivot_log.loc[group_1]
group_2 = 248
for event_number in range(4):
    event = pivot_log.columns[event_number]
    p_value_result = z_test(current_row[event], pivot_log[event][group_2], current_row['total'], pivot_log['total'][group_2], 0.05, 12)
Проверка для групп 1238 и 1230 событие: CartScreenAppear, p-значение: 0.59
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 2476 и 2493 событие: MainScreenAppear, p-значение: 0.52
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 1520 и 1531 событие: OffersScreenAppear, p-значение: 0.93
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 1158 и 1181 событие: PaymentScreenSuccessful, p-значение: 0.73
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
In [48]:
# считаем статистическую значимость между контрольными группами 246+247 и 248:

group_1 = '246_247'
current_row = pivot_log.loc[group_1]
group_2 = 248
for event_number in range(4):
    event = pivot_log.columns[event_number]
    p_value_result = z_test(current_row[event], pivot_log[event][group_2], current_row['total'], pivot_log['total'][group_2], 0.05, 12)
Проверка для групп 2504 и 1230 событие: CartScreenAppear, p-значение: 0.19
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 4926 и 2493 событие: MainScreenAppear, p-значение: 0.35
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 3062 и 1531 событие: OffersScreenAppear, p-значение: 0.45
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Проверка для групп 2358 и 1181 событие: PaymentScreenSuccessful, p-значение: 0.61
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

При множественном тесте с каждой новой проверкой гипотезы растёт вероятность ошибки первого рода.Вероятность того, что хотя бы в одном из 16 сравнений будет зафиксирован ложнопозитивный результат равна: 1(1-0.05)16 = 56%.В случае четырёх групп вероятность хотя бы одного ложнопозитивного результата уже примерно 18.55%.